Skip to content

Add interrupts, memory caps, async streaming, ETF callback results - #70

Merged
benoitc merged 1 commit into
mainfrom
workers-runtime-gaps
Aug 15, 2026
Merged

Add interrupts, memory caps, async streaming, ETF callback results#70
benoitc merged 1 commit into
mainfrom
workers-runtime-gaps

Conversation

@benoitc

@benoitc benoitc commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Closes four gaps that blocked running Python you do not control.

Interrupt running Python

py:interrupt/1 raises KeyboardInterrupt in the thread executing a context;
the in-flight call returns {error, interrupted}. py_context:call/eval/exec
interrupt automatically when their timeout expires, so {error, timeout} now
stops the Python code instead of abandoning the reply while the thread kept
burning CPU and the context stayed wedged.

The BEAM is the watchdog: no C-side timer thread, one new NIF. Works in both
worker and owngil mode (the latter attaches a fresh PyThreadState to the
context's subinterpreter), and is callable while the context process is blocked
in a NIF. Async exceptions land at bytecode boundaries, so code blocked in a C
call is interrupted once that call returns.

Per-context memory caps

py_context:new(#{mode => owngil, memory_limit => Bytes}). Accounting comes
from obmalloc arena traffic, which is the only place a free hook receives the
block size, so no per-object header is needed. Opt-in via
{enable_memory_limits, true}, since the allocator is hooked before Python
starts.

Enforcement raises MemoryError asynchronously rather than returning NULL from
the arena allocator: obmalloc treats a failed arena as a reason to fall back to
PyMem_RawMalloc, so a NULL would let the allocation silently succeed
off-arena and the cap would stop counting instead of stopping the code. Measured
during development: a 64 MB cap let usage reach 82 MB with no error.

Limits are documented: allocations over 512 bytes bypass obmalloc and are not
counted, granularity is one 1 MB arena, and worker mode returns
{error, memory_limit_requires_owngil} because those contexts share the main
interpreter.

Async generator streaming

py:stream_start/3,4 drives async generators on a private event loop. The docs
have claimed this since 3.0.0 with no code behind it; py:stream/4 with kwargs
and py:stream_eval/1,2 stay sync-only, now stated explicitly.

Callback results as external term format

Results returned from an Erlang callback now cross as term_to_binary and are
decoded by the same term_to_py converter used for call arguments, replacing a
Python repr string parsed with ast.literal_eval. Fixes binaries containing
backslashes, quotes, newlines or tabs (which produced an unparseable literal and
were silently delivered to Python as the raw repr text), [] arriving as '',
float precision loss, and the base64 round-trip for pids and refs, which now
cross as native Pid/Ref objects. Removes ~200 lines of duplicated encoders.

Breaking: an Erlang string returned from a callback ("abc", a list of
integers) now reaches Python as [97, 98, 99] rather than 'abc', matching how
call arguments have always converted. Return a binary for a Python str.

Performance

Callback round-trips get faster; hot paths are unchanged. The added per-request
cost is two uncontended mutex pairs, below the noise floor of a 9-15 us
operation.

main branch
ctx eval 14.9 us 16.4 us (within run-to-run variance)
ctx call 8.8 us 9.3 us (within run-to-run variance)
callback, nested map 104.3 us 45.8 us
callback, 8 KB binary 75.3 us 48.1 us

Close four gaps that blocked running untrusted Python: no way to stop
running code, no per-context memory bound, no async-generator streaming,
and a lossy callback result encoding.

py:interrupt/1 raises KeyboardInterrupt in the thread executing a context,
and py_context:call/eval/exec now interrupt on timeout instead of only
abandoning the reply while the thread kept running.

py_context:new(#{mode => owngil, memory_limit => Bytes}) caps memory per
context, accounted from obmalloc arenas and enforced with MemoryError.
Opt-in via enable_memory_limits, since the allocator is hooked before
Python starts.

py:stream_start/3,4 drives async generators again, which the docs had
claimed since 3.0.0 without the code behind it.

Callback results now cross as external term format instead of Python repr
strings parsed with ast.literal_eval, fixing binaries with backslashes,
quotes or newlines, empty lists, float precision, and the base64 pid/ref
round-trip. Breaking: an Erlang string returned from a callback arrives as
a list of integers, matching call arguments.
@benoitc
benoitc merged commit 88f594c into main Aug 15, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant